Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit 5cf433dd26b10d5047defaa3eafff8f189176f7f


Parents : a23b557
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-06-28T18:14:12-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-06-28T23:14:12Z

fix(car): wire notifications & emergency, fix TabTemplate crash, pin car-app to stable (#5997)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

Changes
Diff

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
index 35aa695942..d9b0d0c998 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
@@ -456,6 +456,13 @@ class MeshNotificationManagerImpl(
it.id != SUMMARY_ID && it.notification.group == GROUP_KEY_MESSAGES
}
+ // No conversations left β€” drop the summary too, otherwise it lingers in Android Auto after the
+ // last message notification is cancelled (e.g. on reply / mark-as-read).
+ if (activeNotifications.isEmpty()) {
+ notificationManager.cancel(SUMMARY_ID)
+ return
+ }
+
val ourNode = nodeRepository.value.ourNodeInfo.value
val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
val me =
@@ -517,7 +524,11 @@ class MeshNotificationManagerImpl(
notificationManager.notify(clientNotification.toString().hashCode(), notification)
}
- override fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
+ override fun cancelMessageNotification(contactKey: String) {
+ notificationManager.cancel(contactKey.hashCode())
+ // Rebuild (or clear) the group summary so it doesn't keep showing the dismissed conversation in Android Auto.
+ showGroupSummary()
+ }
override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
@@ -794,6 +805,9 @@ class MeshNotificationManagerImpl(
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, replyLabel, replyPendingIntent)
.addRemoteInput(remoteInput)
+ // Required for Android Auto to drive reply hands-free without opening any UI.
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
+ .setShowsUserInterface(false)
.build()
}
@@ -812,7 +826,11 @@ class MeshNotificationManagerImpl(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
- return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent).build()
+ return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent)
+ // Required for Android Auto to mark a conversation read hands-free without opening any UI.
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
+ .setShowsUserInterface(false)
+ .build()
}
private fun createReactionAction(

diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index 6e295ade64..c03e568d3c 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -20,15 +20,18 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
+import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
+import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ContactKey
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.MeshNotificationManager
+import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioController
/**
@@ -45,31 +48,46 @@ class ReplyReceiver :
private val meshServiceNotifications: MeshNotificationManager by inject()
+ private val packetRepository: PacketRepository by inject()
+
private val dispatchers: CoroutineDispatchers by inject()
private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) }
companion object {
+ private const val TAG = "ReplyReceiver"
const val REPLY_ACTION = "org.meshtastic.app.REPLY_ACTION"
const val CONTACT_KEY = "contactKey"
const val KEY_TEXT_REPLY = "key_text_reply"
}
+ @Suppress("TooGenericExceptionCaught") // a reply must never crash the receiver, whatever the radio throws
override fun onReceive(context: Context, intent: Intent) {
val remoteInput = RemoteInput.getResultsFromIntent(intent)
+ if (remoteInput == null) {
+ Logger.w(tag = TAG) { "reply received but RemoteInput was null" }
+ return
+ }
- if (remoteInput != null) {
- val contactKey = intent.getStringExtra(CONTACT_KEY).orEmpty()
- val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString().orEmpty()
+ val contactKey = intent.getStringExtra(CONTACT_KEY).orEmpty()
+ val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString().orEmpty()
+ Logger.i(tag = TAG) { "reply for contactKey=$contactKey len=${message.length}" }
- val pendingResult = goAsync()
- scope.launch {
- try {
- sendMessage(message, contactKey)
- meshServiceNotifications.cancelMessageNotification(contactKey)
- } finally {
- pendingResult.finish()
- }
+ val pendingResult = goAsync()
+ scope.launch {
+ try {
+ // Send first so the reply isn't lost; then dismiss. The cancel can't break the send.
+ sendMessage(message, contactKey)
+ // Replying implies the conversation has been read β€” mark it so, like the mark-as-read action.
+ // Android Auto keys notification dismissal off read state, not just cancel().
+ packetRepository.clearUnreadCount(contactKey, nowMillis)
+ Logger.i(tag = TAG) { "reply sent + marked read" }
+ } catch (e: Exception) {
+ Logger.e(tag = TAG, throwable = e) { "reply send failed" }
+ } finally {
+ runCatching { meshServiceNotifications.cancelMessageNotification(contactKey) }
+ .onFailure { Logger.e(tag = TAG, throwable = it) { "cancel notification failed" } }
+ pendingResult.finish()
}
}
}

diff --git a/feature/car/TESTING.md b/feature/car/TESTING.md
new file mode 100644
index 0000000000..45f97a883e
--- /dev/null
+++ b/feature/car/TESTING.md
@@ -0,0 +1,49 @@
+# Testing the Car (Android Auto) feature
+
+Two ways to verify `feature:car`. Use the unit tests for correctness; use the DHU only when you
+want to eyeball the real in-car experience end to end.
+
+## 1. Automated β€” `SessionController` tests (no hardware, runs in CI)
+
+```bash
+./gradlew :feature:car:testGoogleDebugUnitTest
+```
+
+`CarScreensTest` drives the real `HomeScreen` through the androidx.car.app testing context, pushing
+state via the `CarStateCoordinator` test seam (`setStateForTest`), and asserts the right template
+renders per connection state plus the `ALERT_APP` β†’ emergency flow. This is what catches regressions
+(e.g. it caught the `TabTemplate.setActiveTabContentId` crash the 1.7.0 pin introduced). Robolectric
+is pinned to `@Config(sdk = [36])` because no Robolectric SDK-37 sandbox exists yet β€” harmless, the
+templates are SDK-agnostic.
+
+## 2. Visual β€” Desktop Head Unit (DHU)
+
+### Platform matters
+DHU decodes an H.264 video stream from the phone. **macOS Apple Silicon (`2.1-mac-arm64`) fails at
+this step** β€” it connects and reports `Has video focus: true` but renders no frames. Use an
+**x86_64 Linux (or Windows) host**; `linux-arm64` DHU does not exist, and an emulated x86 VM has no
+hardware decode (same failure). A VM is fine **only on an x86_64 host with real virtualization**.
+
+### Phone prep (one time)
+1. Settings β†’ About β†’ tap *Build number* Γ—7 β†’ enable **USB debugging** and **Wireless debugging**.
+2. Open Android Auto settings (on a Pixel: `adb shell am start -n com.google.android.projection.gearhead/.companion.settings.DefaultSettingsActivity`),
+ tap *Version* ~10Γ— to unlock **Developer settings**, then enable **Start head unit server**.
+
+### On the x86_64 box
+```bash
+sdkmanager "extras;google;auto" # installs the linux-x86_64 DHU
+# Connect to the phone over wifi (no re-plugging; works even if the phone lives on another machine):
+adb pair <phone-ip>:<pair-port> # one-time; code shown under Wireless debugging
+adb connect <phone-ip>:<debug-port>
+adb forward tcp:5277 tcp:5277
+cd "$ANDROID_SDK_ROOT/extras/google/auto" && ./desktop-head-unit
+```
+DHU commands: type `help` in its shell; `focus video`, `tap <x> <y>`, `screenshot <file>`.
+
+### Caveat: our app has no car-launcher tile
+`feature:car` is a **MESSAGING**-category app. It does not appear as an icon on the Android Auto
+home β€” it surfaces when a **message notification** arrives. To see the `ConversationItem` UI in the
+DHU you must have a **paired Meshtastic radio sending a text**; the notification (read-aloud + reply)
+is the entry point. Without a radio you'll see the AA home, not our screens. (For the same reason,
+the AAOS emulator is not a fit either: messaging isn't an AAOS distribution category, and the app is
+projection-only β€” no `CarAppActivity`.)

diff --git a/feature/car/build.gradle.kts b/feature/car/build.gradle.kts
index 43b532bf7d..93592f82ff 100644
--- a/feature/car/build.gradle.kts
+++ b/feature/car/build.gradle.kts
@@ -19,6 +19,7 @@ plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.flavors)
id("meshtastic.koin")
+ id("dev.mokkery")
}
android {
@@ -30,6 +31,9 @@ android {
minSdk = 23
consumerProguardFiles("proguard-rules.pro")
}
+
+ // Robolectric provides the Android context that androidx.car.app TestCarContext/ScreenController need.
+ testOptions { unitTests { isIncludeAndroidResources = true } }
}
dependencies {
@@ -52,6 +56,7 @@ dependencies {
testImplementation(libs.androidx.car.app.testing)
testImplementation(libs.koin.test)
+ testImplementation(libs.robolectric)
testImplementation(kotlin("test-junit"))
testRuntimeOnly(libs.junit.vintage.engine)
}

diff --git a/feature/car/src/main/AndroidManifest.xml b/feature/car/src/main/AndroidManifest.xml
index c0706eac9d..34bbec51c6 100644
--- a/feature/car/src/main/AndroidManifest.xml
+++ b/feature/car/src/main/AndroidManifest.xml
@@ -12,12 +12,14 @@
</intent-filter>
</service>
- <receiver
- android:name="org.meshtastic.feature.car.service.CarReplyReceiver"
- android:exported="false" />
-
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="7" />
+
+ <!-- Required for Android Auto to surface MessagingStyle notifications and recognize the
+ template app. Without this the automotive_app_desc declarations never reach the host. -->
+ <meta-data
+ android:name="com.google.android.gms.car.application"
+ android:resource="@xml/automotive_app_desc" />
</application>
</manifest>

diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt
index bec9060106..6e72e6022c 100644
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/HomeScreen.kt
@@ -188,6 +188,7 @@ class HomeScreen(
addTab(messagingTab)
addTab(nodesTab)
addTab(statusTab)
+ setActiveTabContentId(selectedTabId)
setTabContents(getTabContents())
}
.build()

diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt
deleted file mode 100644
index 216e95cebf..0000000000
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarNotificationManager.kt
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-package org.meshtastic.feature.car.service
-
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.content.Context
-import android.content.Intent
-import android.os.Build
-import androidx.core.app.NotificationCompat
-import androidx.core.app.NotificationManagerCompat
-import androidx.core.app.Person
-import androidx.core.app.RemoteInput
-import androidx.core.content.LocusIdCompat
-import org.koin.core.annotation.Single
-import org.meshtastic.feature.car.R
-
-@Single
-class CarNotificationManager(private val context: Context, private val shortcutManager: ConversationShortcutManager) {
-
- init {
- createNotificationChannel()
- }
-
- private fun createNotificationChannel() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val channel =
- NotificationChannel(CHANNEL_ID, "Mesh Messages", NotificationManager.IMPORTANCE_HIGH).apply {
- description = "Messages from Meshtastic mesh network"
- }
- val manager = context.getSystemService(NotificationManager::class.java)
- manager.createNotificationChannel(channel)
- }
- }
-
- fun postMessagingNotification(conversationId: String, senderName: String, messages: List<Pair<String, Long>>) {
- shortcutManager.ensureConversationShortcut(conversationId, senderName)
-
- val person = Person.Builder().setName(senderName).build()
-
- val messagingStyle = NotificationCompat.MessagingStyle(Person.Builder().setName("Me").build())
- messagingStyle.setConversationTitle(senderName)
- messages.forEach { (text, timestamp) -> messagingStyle.addMessage(text, timestamp, person) }
-
- val replyAction = buildReplyAction(conversationId)
- val markReadAction = buildMarkReadAction(conversationId)
-
- val notification =
- NotificationCompat.Builder(context, CHANNEL_ID)
- .setSmallIcon(R.drawable.ic_car_meshtastic)
- .setStyle(messagingStyle)
- .addAction(replyAction)
- .addAction(markReadAction)
- .setCategory(NotificationCompat.CATEGORY_MESSAGE)
- .setShortcutId(conversationId)
- .setLocusId(LocusIdCompat(conversationId))
- .build()
-
- NotificationManagerCompat.from(context).notify(conversationId.hashCode(), notification)
- }
-
- private fun buildReplyAction(conversationId: String): NotificationCompat.Action {
- val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel("Reply").build()
-
- val replyIntent =
- PendingIntent.getBroadcast(
- context,
- conversationId.hashCode(),
- Intent(context, CarReplyReceiver::class.java)
- .setAction(ACTION_REPLY)
- .putExtra(EXTRA_CONVERSATION_ID, conversationId),
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
- )
-
- return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, "Reply", replyIntent)
- .addRemoteInput(remoteInput)
- .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
- .setShowsUserInterface(false)
- .build()
- }
-
- private fun buildMarkReadAction(conversationId: String): NotificationCompat.Action {
- val markReadIntent =
- PendingIntent.getBroadcast(
- context,
- conversationId.hashCode() + 1,
- Intent(context, CarReplyReceiver::class.java)
- .setAction(ACTION_MARK_READ)
- .putExtra(EXTRA_CONVERSATION_ID, conversationId),
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
-
- return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, "Mark as Read", markReadIntent)
- .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
- .setShowsUserInterface(false)
- .build()
- }
-
- companion object {
- const val CHANNEL_ID = "meshtastic_car_messages"
- const val KEY_TEXT_REPLY = "key_text_reply"
- const val ACTION_REPLY = "org.meshtastic.feature.car.REPLY"
- const val ACTION_MARK_READ = "org.meshtastic.feature.car.MARK_READ"
- const val EXTRA_CONVERSATION_ID = "conversation_id"
- }
-}

diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarReplyReceiver.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarReplyReceiver.kt
deleted file mode 100644
index 8ddc890bc2..0000000000
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarReplyReceiver.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-package org.meshtastic.feature.car.service
-
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import androidx.core.app.RemoteInput
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.launch
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.inject
-import org.meshtastic.core.repository.PacketRepository
-import org.meshtastic.core.repository.usecase.SendMessageUseCase
-
-/**
- * Handles inline reply and mark-read actions from car messaging notifications. Uses [goAsync] to keep the receiver
- * alive while the coroutine completes, preventing premature process kill.
- */
-class CarReplyReceiver :
- BroadcastReceiver(),
- KoinComponent {
-
- private val sendMessageUseCase: SendMessageUseCase by inject()
- private val packetRepository: PacketRepository by inject()
-
- override fun onReceive(context: Context, intent: Intent) {
- val pendingResult = goAsync()
- val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
- scope.launch {
- try {
- when (intent.action) {
- CarNotificationManager.ACTION_REPLY -> handleReply(intent)
- CarNotificationManager.ACTION_MARK_READ -> handleMarkRead(intent)
- }
- } finally {
- pendingResult.finish()
- }
- }
- }
-
- private suspend fun handleReply(intent: Intent) {
- val conversationId = intent.getStringExtra(CarNotificationManager.EXTRA_CONVERSATION_ID) ?: return
- val remoteInput = RemoteInput.getResultsFromIntent(intent)
- val replyText = remoteInput?.getCharSequence(CarNotificationManager.KEY_TEXT_REPLY)?.toString() ?: return
-
- Logger.d(tag = TAG) { "Reply to conversation: $conversationId (${replyText.length} chars)" }
- runCatching { sendMessageUseCase(replyText, conversationId) }
- .onFailure { error -> Logger.e(tag = TAG, throwable = error) { "Failed to send reply" } }
- }
-
- private suspend fun handleMarkRead(intent: Intent) {
- val conversationId = intent.getStringExtra(CarNotificationManager.EXTRA_CONVERSATION_ID) ?: return
- Logger.d(tag = TAG) { "Mark read: $conversationId" }
- runCatching { packetRepository.clearUnreadCount(conversationId, System.currentTimeMillis()) }
- .onFailure { error -> Logger.e(tag = TAG, throwable = error) { "Failed to mark as read" } }
- }
-
- companion object {
- private const val TAG = "CarReplyReceiver"
- }
-}

diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt
index ec2833bff8..68f725bfa6 100644
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.car.service
+import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -27,10 +28,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -40,11 +45,13 @@ import org.meshtastic.feature.car.model.CarLocalStats
import org.meshtastic.feature.car.model.CarSessionState
import org.meshtastic.feature.car.model.ChannelUi
import org.meshtastic.feature.car.model.ConversationUi
+import org.meshtastic.feature.car.model.EmergencyAlert
import org.meshtastic.feature.car.model.MessagingUiState
import org.meshtastic.feature.car.model.NodeDashboardUiState
import org.meshtastic.feature.car.model.TopologyHeader
import org.meshtastic.feature.car.util.CarScreenDataBuilder
import org.meshtastic.feature.car.util.MessageFilter
+import org.meshtastic.proto.PortNum
/** Snapshot of a message for car display (avoids leaking domain models to UI). */
data class MessageSnapshot(
@@ -106,6 +113,46 @@ class CarStateCoordinator(
private val _localStatsState = MutableStateFlow(CarLocalStats())
val localStatsState: StateFlow<CarLocalStats> = _localStatsState.asStateFlow()
+ /** Test seam: push presentation state directly without driving the repositories (see CarScreensTest). */
+ @VisibleForTesting
+ internal fun setStateForTest(
+ session: CarSessionState = _sessionState.value,
+ messaging: MessagingUiState = _messagingState.value,
+ nodes: NodeDashboardUiState = _nodeDashboardState.value,
+ stats: CarLocalStats = _localStatsState.value,
+ ) {
+ _sessionState.value = session
+ _messagingState.value = messaging
+ _nodeDashboardState.value = nodes
+ _localStatsState.value = stats
+ }
+
+ /**
+ * Emits an [EmergencyAlert] for each new incoming ALERT_APP packet. Sourced from the contacts flow (last packet per
+ * contact), deduped by packet id so the same alert isn't re-raised. ponytail: an alert immediately superseded by a
+ * newer message in the same contact within one emission can be missed β€” acceptable for the in-car overlay; the core
+ * alert notification still fires regardless.
+ */
+ val emergencyAlerts: kotlinx.coroutines.flow.Flow<EmergencyAlert> =
+ packetRepository
+ .getContacts()
+ .mapNotNull { contacts ->
+ contacts.values.filter { it.dataType == PortNum.ALERT_APP.value }.maxByOrNull { it.time }
+ }
+ .distinctUntilChangedBy { it.id }
+ .map { it.toEmergencyAlert() }
+
+ private fun DataPacket.toEmergencyAlert(): EmergencyAlert {
+ val entry = nodeRepository.nodeDBbyNum.value.entries.find { it.value.user.id == from }
+ return EmergencyAlert(
+ nodeNum = entry?.key ?: 0,
+ nodeName = entry?.value?.user?.long_name ?: from ?: "Unknown",
+ message = alert ?: "",
+ timestamp = time,
+ isActive = true,
+ )
+ }
+
private val selectedChannel = MutableStateFlow(0)
fun start() {
@@ -186,7 +233,7 @@ class CarStateCoordinator(
) { nodeMap, onlineCount, localConfig ->
val nodes = CarScreenDataBuilder.sortNodes(nodeMap.values, localConfig.lora?.modem_preset)
val totalCount = nodeMap.size
- val meshName = nodeRepository.myNodeInfo.value?.firmwareVersion
+ val meshName = nodeRepository.ourNodeInfo.value?.user?.long_name
_nodeDashboardState.value =
NodeDashboardUiState(

diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt
index 29798dedce..225d4a42b7 100644
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt
@@ -26,7 +26,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.emptyFlow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.feature.car.alerts.EmergencyHandler
@@ -47,8 +46,7 @@ class MeshtasticCarSession :
crashlyticsCarTagger.setCarSession(true)
stateCoordinator.start()
conversationShortcutManager.startObserving(sessionScope)
- // Emergency flow wired to emptyFlow() until emergency packet detection is implemented
- emergencyHandler.startCollecting(emptyFlow())
+ emergencyHandler.startCollecting(stateCoordinator.emergencyAlerts)
lifecycle.addObserver(
object : DefaultLifecycleObserver {

diff --git a/feature/car/src/test/kotlin/org/meshtastic/feature/car/CarScreensTest.kt b/feature/car/src/test/kotlin/org/meshtastic/feature/car/CarScreensTest.kt
new file mode 100644
index 0000000000..6c8a982eb2
--- /dev/null
+++ b/feature/car/src/test/kotlin/org/meshtastic/feature/car/CarScreensTest.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.car
+
+import androidx.car.app.model.PaneTemplate
+import androidx.car.app.model.TabTemplate
+import androidx.car.app.testing.TestCarContext
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import okio.ByteString.Companion.encodeUtf8
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.usecase.SendMessageUseCase
+import org.meshtastic.feature.car.alerts.EmergencyHandler
+import org.meshtastic.feature.car.model.CarSessionState
+import org.meshtastic.feature.car.model.ChannelUi
+import org.meshtastic.feature.car.model.MessagingUiState
+import org.meshtastic.feature.car.screens.HomeScreen
+import org.meshtastic.feature.car.service.CarStateCoordinator
+import org.meshtastic.feature.car.util.MessageFilter
+import org.meshtastic.proto.PortNum
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+/**
+ * Drives the real [HomeScreen] (via the androidx.car.app testing context) with state pushed through the
+ * [CarStateCoordinator] test seam, asserting the correct template renders per connection state, and verifies the
+ * emergency flow turns an ALERT_APP packet into an [org.meshtastic.feature.car.model.EmergencyAlert].
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36]) // Robolectric has no SDK 37 jar yet; the car templates are SDK-agnostic.
+class CarScreensTest {
+
+ private val packetRepo = mock<PacketRepository>(MockMode.autofill)
+ private val nodeRepo = mock<NodeRepository>(MockMode.autofill)
+
+ private fun coordinator(): CarStateCoordinator {
+ every { packetRepo.getContacts() } returns flowOf(emptyMap())
+ every { nodeRepo.nodeDBbyNum } returns MutableStateFlow(emptyMap<Int, Node>())
+ return CarStateCoordinator(
+ nodeRepository = nodeRepo,
+ packetRepository = packetRepo,
+ serviceRepository = mock<ServiceRepository>(MockMode.autofill),
+ radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill),
+ sendMessageUseCase = mock<SendMessageUseCase>(MockMode.autofill),
+ messageFilter = MessageFilter(),
+ )
+ }
+
+ private fun homeScreen(coord: CarStateCoordinator): HomeScreen {
+ val ctx = TestCarContext.createCarContext(RuntimeEnvironment.getApplication())
+ return HomeScreen(ctx, coord, EmergencyHandler())
+ }
+
+ private fun session(state: ConnectionState) = CarSessionState(
+ connectionStatus = state,
+ onlineNodeCount = 0,
+ lastMessageTime = null,
+ activeEmergencies = emptyList(),
+ meshName = "Test Mesh",
+ )
+
+ @Test
+ fun `disconnected renders a pane template`() {
+ val coord = coordinator().apply { setStateForTest(session = session(ConnectionState.Disconnected)) }
+ assertTrue(homeScreen(coord).onGetTemplate() is PaneTemplate)
+ }
+
+ @Test
+ fun `connected with no channels renders the onboarding pane`() {
+ val coord =
+ coordinator().apply {
+ setStateForTest(
+ session = session(ConnectionState.Connected),
+ messaging = MessagingUiState(emptyList(), 0, emptyList(), null),
+ )
+ }
+ assertTrue(homeScreen(coord).onGetTemplate() is PaneTemplate)
+ }
+
+ @Test
+ fun `connected with channels renders the tab template`() {
+ val coord =
+ coordinator().apply {
+ setStateForTest(
+ session = session(ConnectionState.Connected),
+ messaging = MessagingUiState(listOf(ChannelUi(0, "LongFast", 0)), 0, emptyList(), null),
+ )
+ }
+ assertTrue(homeScreen(coord).onGetTemplate() is TabTemplate)
+ }
+
+ @Test
+ fun `ALERT_APP packet becomes an emergency alert`() = runBlocking {
+ every { nodeRepo.nodeDBbyNum } returns MutableStateFlow(emptyMap<Int, Node>())
+ val alert =
+ DataPacket(
+ to = null,
+ bytes = "HELP".encodeUtf8(),
+ dataType = PortNum.ALERT_APP.value,
+ from = "!abcd1234",
+ time = 100L,
+ id = 7,
+ )
+ every { packetRepo.getContacts() } returns flowOf(mapOf("k" to alert))
+ val coord =
+ CarStateCoordinator(
+ nodeRepository = nodeRepo,
+ packetRepository = packetRepo,
+ serviceRepository = mock<ServiceRepository>(MockMode.autofill),
+ radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill),
+ sendMessageUseCase = mock<SendMessageUseCase>(MockMode.autofill),
+ messageFilter = MessageFilter(),
+ )
+
+ val emergency = coord.emergencyAlerts.first()
+
+ assertEquals("HELP", emergency.message)
+ assertEquals("!abcd1234", emergency.nodeName)
+ assertTrue(emergency.isActive)
+ }
+}

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index dfd4a2397f..32fc45e3c4 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,7 +4,7 @@ xmlutil = "0.91.3"
# Android
agp = "9.2.1"
appcompat = "1.7.1"
-car-app = "1.9.0-alpha01"
+car-app = "1.7.0"
appfunctions = "1.0.0-alpha09"
# androidx
@@ -121,7 +121,6 @@ androidx-camera-compose = { module = "androidx.camera:camera-compose", version.r
androidx-camera-viewfinder-compose = { module = "androidx.camera.viewfinder:viewfinder-compose", version = "1.6.1" }
androidx-car-app = { module = "androidx.car.app:app", version.ref = "car-app" }
androidx-car-app-projected = { module = "androidx.car.app:app-projected", version.ref = "car-app" }
-androidx-car-app-automotive = { module = "androidx.car.app:app-automotive", version.ref = "car-app" }
androidx-car-app-testing = { module = "androidx.car.app:app-testing", version.ref = "car-app" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.19.0" }
androidx-core-location-altitude = { module = "androidx.core:core-location-altitude", version = "1.0.0" }

Served by rngit 1.5.4 - Generated in 0.2s